--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit d801d095deb16e340d0dd8917193861b60527699
Parents : e1cb2d9
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-16T08:51:05-05:00
feat: add system resource monitoring with CPU and memory usage insights in the UI
Changes
20 files changed, 912 insertions(+), 332 deletions(-)
Diff
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 3fb5d9e1..5e430c12 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 5af57855..a72a3e79 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -524,6 +524,15 @@ class ReticulumMeshChat:
broadcast_event=self._on_rns_link_broadcast,
)
self.memory_pressure = MemoryPressureManager(app=self)
+ from meshchatx.src.backend.battery_usage_estimate import BatteryUsageTracker
+
+ self.battery_usage = BatteryUsageTracker()
+ try:
+ self._host_process = psutil.Process()
+ # Prime cpu_percent so later non-blocking samples are meaningful.
+ self._host_process.cpu_percent(interval=None)
+ except Exception:
+ self._host_process = None
# Track long-running rns.link.* handler tasks per WS client so they can
# be cancelled when the client disconnects.
self._rns_link_tasks: dict[web.WebSocketResponse, set[asyncio.Task]] = {}
@@ -7366,9 +7375,21 @@ class ReticulumMeshChat:
# get app info
@routes.get("/api/v1/app/info")
async def app_info(request):
- process = psutil.Process()
+ process = getattr(self, "_host_process", None)
+ if process is None:
+ try:
+ process = psutil.Process()
+ except Exception:
+ process = None
def _safe_memory_info():
+ if process is None:
+
+ class _M:
+ rss = 0
+ vms = 0
+
+ return _M()
try:
return process.memory_info()
except Exception:
@@ -7379,6 +7400,43 @@ class ReticulumMeshChat:
return _M()
+ def _safe_process_usage():
+ usage = {
+ "cpu_percent": None,
+ "num_threads": None,
+ "create_time": None,
+ "cpu_time_seconds": None,
+ }
+ if process is None:
+ return usage
+ try:
+ usage["cpu_percent"] = float(process.cpu_percent(interval=None))
+ except Exception:
+ pass
+ try:
+ usage["num_threads"] = int(process.num_threads())
+ except Exception:
+ pass
+ try:
+ usage["create_time"] = float(process.create_time())
+ except Exception:
+ pass
+ try:
+ times = process.cpu_times()
+ usage["cpu_time_seconds"] = float(times.user) + float(times.system)
+ except Exception:
+ pass
+ return usage
+
+ def _safe_battery_usage():
+ tracker = getattr(self, "battery_usage", None)
+ if tracker is None:
+ return None
+ try:
+ return tracker.snapshot(process)
+ except Exception:
+ return None
+
def _safe_net_io():
try:
return psutil.net_io_counters()
@@ -7394,6 +7452,8 @@ class ReticulumMeshChat:
# psutil often raises on Android (restricted /proc), so never fail the whole payload.
memory_info = _safe_memory_info()
+ process_usage = _safe_process_usage()
+ battery_usage = _safe_battery_usage()
net_io = _safe_net_io()
def _safe_database_path():
@@ -7451,26 +7511,31 @@ class ReticulumMeshChat:
if is_connected_to_shared_instance:
# Try to find the shared instance address from active connections
try:
- for conn in process.net_connections(kind="all"):
- if conn.status == psutil.CONN_ESTABLISHED and conn.raddr:
- # Check for common Reticulum shared instance ports or UNIX sockets
+ if process is not None:
+ for conn in process.net_connections(kind="all"):
if (
- isinstance(conn.raddr, tuple)
- and conn.raddr[1] == 37428
+ conn.status == psutil.CONN_ESTABLISHED
+ and conn.raddr
):
- shared_instance_address = (
- f"{conn.raddr[0]}:{conn.raddr[1]}"
- )
- break
- if (
- isinstance(conn.raddr, str)
- and (
- "rns" in conn.raddr or "reticulum" in conn.raddr
- )
- and ".sock" in conn.raddr
- ):
- shared_instance_address = conn.raddr
- break
+ # Check for common Reticulum shared instance ports or UNIX sockets
+ if (
+ isinstance(conn.raddr, tuple)
+ and conn.raddr[1] == 37428
+ ):
+ shared_instance_address = (
+ f"{conn.raddr[0]}:{conn.raddr[1]}"
+ )
+ break
+ if (
+ isinstance(conn.raddr, str)
+ and (
+ "rns" in conn.raddr
+ or "reticulum" in conn.raddr
+ )
+ and ".sock" in conn.raddr
+ ):
+ shared_instance_address = conn.raddr
+ break
except Exception:
pass
@@ -7612,9 +7677,14 @@ class ReticulumMeshChat:
else False
),
"memory_usage": {
- "rss": memory_info.rss, # Resident Set Size (bytes)
- "vms": memory_info.vms, # Virtual Memory Size (bytes)
+ "rss": memory_info.rss,
+ "vms": memory_info.vms,
+ "cpu_percent": process_usage.get("cpu_percent"),
+ "num_threads": process_usage.get("num_threads"),
+ "create_time": process_usage.get("create_time"),
+ "cpu_time_seconds": process_usage.get("cpu_time_seconds"),
},
+ "battery_usage": battery_usage,
"network_stats": {
"bytes_sent": net_io.bytes_sent,
"bytes_recv": net_io.bytes_recv,
diff --git a/meshchatx/src/backend/battery_usage_estimate.py b/meshchatx/src/backend/battery_usage_estimate.py
new file mode 100644
index 00000000..2c28806c
--- /dev/null
+++ b/meshchatx/src/backend/battery_usage_estimate.py
@@ -0,0 +1,195 @@
+# SPDX-License-Identifier: 0BSD
+"""Estimate MeshChatX battery drain from process CPU time.
+
+OS APIs rarely expose per-app battery without privileged permissions.
+This module derives a conservative estimate from cumulative process CPU
+time versus wall-clock uptime so About can show app-level usage instead
+of only the host pack percentage.
+"""
+
+from __future__ import annotations
+
+import os
+import time
+from typing import Any
+
+# Rough calibration: one busy logical core ~ this many battery percent per hour
+# on a typical phone or thin laptop. Tuned to be conservative and capped.
+_ONE_CORE_PERCENT_PER_HOUR = 10.0
+_MAX_PERCENT_PER_HOUR = 40.0
+_MIN_UPTIME_FOR_RATE_S = 30.0
+
+
+def _clamp(value: float, low: float, high: float) -> float:
+ return max(low, min(high, value))
+
+
+def drain_intensity(estimated_percent_per_hour: float | None) -> str | None:
+ """Map an estimated %/hr rate to a coarse intensity label."""
+ if estimated_percent_per_hour is None:
+ return None
+ if estimated_percent_per_hour < 0.5:
+ return "low"
+ if estimated_percent_per_hour < 2.0:
+ return "moderate"
+ if estimated_percent_per_hour < 6.0:
+ return "high"
+ return "very_high"
+
+
+def estimate_battery_usage(
+ *,
+ cpu_time_seconds: float | None,
+ uptime_seconds: float | None,
+ cpu_count: int | None = 1,
+ on_battery: bool | None = None,
+ host_level: int | None = None,
+) -> dict[str, Any] | None:
+ """Build an estimated MeshChatX battery-usage payload.
+
+ ``avg_cpu_percent`` is percent of one logical core (may exceed 100 on
+ multi-threaded work). ``machine_share_percent`` normalizes by CPU count.
+ ``estimated_percent_per_hour`` is a rough battery pack drain rate.
+ """
+ if cpu_time_seconds is None or uptime_seconds is None:
+ return None
+ try:
+ cpu_time = float(cpu_time_seconds)
+ uptime = float(uptime_seconds)
+ except (TypeError, ValueError):
+ return None
+ if cpu_time < 0 or uptime <= 0:
+ return None
+
+ cores = 1
+ if cpu_count is not None:
+ try:
+ cores = max(1, int(cpu_count))
+ except (TypeError, ValueError):
+ cores = 1
+
+ one_core_fraction = cpu_time / uptime
+ avg_cpu_percent = one_core_fraction * 100.0
+ machine_share_percent = _clamp((one_core_fraction / cores) * 100.0, 0.0, 100.0)
+
+ estimated_percent_per_hour = None
+ confidence = "warming_up"
+ if uptime >= _MIN_UPTIME_FOR_RATE_S:
+ estimated_percent_per_hour = _clamp(
+ one_core_fraction * _ONE_CORE_PERCENT_PER_HOUR,
+ 0.0,
+ _MAX_PERCENT_PER_HOUR,
+ )
+ confidence = "estimate"
+
+ intensity = drain_intensity(estimated_percent_per_hour)
+
+ return {
+ "avg_cpu_percent": round(avg_cpu_percent, 1),
+ "machine_share_percent": round(machine_share_percent, 1),
+ "estimated_percent_per_hour": (
+ round(estimated_percent_per_hour, 1)
+ if estimated_percent_per_hour is not None
+ else None
+ ),
+ "intensity": intensity,
+ "cpu_time_seconds": round(cpu_time, 2),
+ "uptime_seconds": round(uptime, 1),
+ "cpu_count": cores,
+ "method": "cpu_time",
+ "confidence": confidence,
+ "on_battery": on_battery,
+ "host_level": host_level,
+ }
+
+
+def read_linux_host_battery() -> tuple[int | None, bool | None]:
+ """Best-effort host pack reading from sysfs (Linux laptops and some SBCs)."""
+ power_root = "/sys/class/power_supply"
+ if not os.path.isdir(power_root):
+ return None, None
+ try:
+ entries = sorted(os.listdir(power_root))
+ except OSError:
+ return None, None
+
+ level = None
+ charging = None
+ for name in entries:
+ base = os.path.join(power_root, name)
+ type_path = os.path.join(base, "type")
+ try:
+ with open(type_path, encoding="utf-8") as handle:
+ supply_type = handle.read().strip().lower()
+ except OSError:
+ continue
+ if supply_type != "battery":
+ continue
+ capacity_path = os.path.join(base, "capacity")
+ status_path = os.path.join(base, "status")
+ try:
+ with open(capacity_path, encoding="utf-8") as handle:
+ raw = int(handle.read().strip())
+ if 0 <= raw <= 100:
+ level = raw
+ except (OSError, ValueError):
+ pass
+ try:
+ with open(status_path, encoding="utf-8") as handle:
+ status = handle.read().strip().lower()
+ if status in ("charging", "full"):
+ charging = True
+ elif status in ("discharging", "not charging"):
+ charging = False
+ except OSError:
+ pass
+ if level is not None:
+ break
+ return level, charging
+
+
+class BatteryUsageTracker:
+ """Snapshot MeshChatX process CPU into a battery-usage estimate."""
+
+ def __init__(self) -> None:
+ self._last: dict[str, Any] | None = None
+
+ def snapshot(self, process: Any) -> dict[str, Any] | None:
+ if process is None:
+ return self._last
+ try:
+ times = process.cpu_times()
+ cpu_time = float(getattr(times, "user", 0.0)) + float(
+ getattr(times, "system", 0.0)
+ )
+ except Exception:
+ return self._last
+ try:
+ create_time = float(process.create_time())
+ except Exception:
+ return self._last
+ uptime = max(0.0, time.time() - create_time)
+ try:
+ import psutil
+
+ cpu_count = psutil.cpu_count(logical=True) or 1
+ except Exception:
+ cpu_count = 1
+
+ host_level, charging = read_linux_host_battery()
+ on_battery = None
+ if charging is True:
+ on_battery = False
+ elif charging is False:
+ on_battery = True
+
+ estimate = estimate_battery_usage(
+ cpu_time_seconds=cpu_time,
+ uptime_seconds=uptime,
+ cpu_count=cpu_count,
+ on_battery=on_battery,
+ host_level=host_level,
+ )
+ if estimate is not None:
+ self._last = estimate
+ return estimate
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index c7284e3e..aa0fc840 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -91,7 +91,6 @@
/>
</button>
<LanguageSelector class="hidden sm:block" @language-change="onLanguageChange" />
- <BatteryStatusChip />
<NotificationBell />
<button
type="button"
@@ -595,7 +594,6 @@ import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import QRCode from "qrcode";
import NotificationBell from "./NotificationBell.vue";
import LanguageSelector from "./LanguageSelector.vue";
-import BatteryStatusChip from "./layout/BatteryStatusChip.vue";
import CallOverlay from "./call/CallOverlay.vue";
import CommandPalette from "./CommandPalette.vue";
import IntegrityWarningModal from "./IntegrityWarningModal.vue";
@@ -625,7 +623,6 @@ export default {
MaterialDesignIcon,
NotificationBell,
LanguageSelector,
- BatteryStatusChip,
CallOverlay,
CommandPalette,
IntegrityWarningModal,
diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 109e7edf..e5ebad71 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -459,21 +459,103 @@
}}</span>
<span class="font-mono text-xs font-bold">{{ environmentInfo.platform }}</span>
</div>
- <div class="flex items-center justify-between gap-3">
- <span class="text-[10px] font-black text-lime-600 uppercase tracking-wider">{{
- $t("about.env_battery")
+ <div
+ v-if="appInfo.memory_usage || appInfo.battery_usage"
+ class="flex flex-col gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800"
+ >
+ <span class="text-[10px] font-black text-cyan-600 uppercase tracking-wider">{{
+ $t("about.usage_insights")
}}</span>
- <span
- class="font-mono text-xs font-bold shrink-0 inline-flex items-center gap-1"
- :class="batteryStatusToneClass"
+ <div v-if="batteryUsageLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.app_battery_use")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold tabular-nums shrink-0"
+ :class="batteryUsageToneClass"
+ :title="$t('about.app_battery_use_hint')"
+ >
+ {{ batteryUsageLabel }}
+ </span>
+ </div>
+ <div v-if="batteryUsageShareLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.app_battery_share")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ batteryUsageShareLabel
+ }}</span>
+ </div>
+ <div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.memory_rss")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ formatBytes(appInfo.memory_usage.rss || 0)
+ }}</span>
+ </div>
+ <div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.virtual_memory")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ formatBytes(appInfo.memory_usage.vms || 0)
+ }}</span>
+ </div>
+ <div
+ v-if="appInfo.memory_usage && appInfo.memory_usage.cpu_percent != null"
+ class="flex items-center justify-between gap-3"
>
- <v-icon
- v-if="batteryStatus"
- :icon="'mdi-' + batteryStatusIcon"
- size="14"
- ></v-icon>
- {{ batteryStatusLabel }}
- </span>
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.process_cpu")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ formatCpuPercent(appInfo.memory_usage.cpu_percent)
+ }}</span>
+ </div>
+ <div
+ v-if="appInfo.memory_usage && appInfo.memory_usage.num_threads != null"
+ class="flex items-center justify-between gap-3"
+ >
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.process_threads")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ appInfo.memory_usage.num_threads
+ }}</span>
+ </div>
+ <div v-if="processUptimeLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.process_uptime")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ processUptimeLabel
+ }}</span>
+ </div>
+ <div v-if="memoryPressureLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.memory_pressure")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ memoryPressureLabel
+ }}</span>
+ </div>
+ <div v-if="showHostBattery" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.env_host_battery")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold shrink-0 inline-flex items-center gap-1"
+ :class="batteryStatusToneClass"
+ >
+ <v-icon
+ v-if="batteryStatus"
+ :icon="'mdi-' + batteryStatusIcon"
+ size="14"
+ ></v-icon>
+ {{ batteryStatusLabel }}
+ </span>
+ </div>
</div>
<div
v-if="isLinuxHost && appInfo.landlock_requested !== undefined"
@@ -1089,7 +1171,15 @@ import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
-import { batteryStatusIconName, getDeviceBatteryStatus } from "../../js/deviceBattery.js";
+import {
+ appBatteryUsageToneClass,
+ batteryStatusIconName,
+ formatAppBatteryShareLabel,
+ formatAppBatteryUsageLabel,
+ formatProcessUptime,
+ getDeviceBatteryStatus,
+ isNativeBatteryStatus,
+} from "../../js/deviceBattery.js";
export default {
name: "AboutPage",
components: {},
@@ -1182,6 +1272,35 @@ export default {
batteryStatusIcon() {
return batteryStatusIconName(this.batteryStatus);
},
+ showHostBattery() {
+ return isNativeBatteryStatus(this.batteryStatus);
+ },
+ batteryUsageLabel() {
+ return formatAppBatteryUsageLabel(this.appInfo?.battery_usage, (key, values) => this.$t(key, values));
+ },
+ batteryUsageShareLabel() {
+ return formatAppBatteryShareLabel(this.appInfo?.battery_usage, (key, values) => this.$t(key, values));
+ },
+ batteryUsageToneClass() {
+ return appBatteryUsageToneClass(this.appInfo?.battery_usage);
+ },
+ processUptimeLabel() {
+ return formatProcessUptime(this.appInfo?.memory_usage?.create_time);
+ },
+ memoryPressureLabel() {
+ const cleanup = this.appInfo?.reticulum_stats?.memory_cleanup;
+ if (!cleanup || typeof cleanup !== "object") {
+ return null;
+ }
+ if (cleanup.sqlite_relaxed) {
+ return this.$t("about.memory_pressure_relaxed");
+ }
+ const pathSize = cleanup.path_table_size;
+ if (pathSize != null) {
+ return this.$t("about.memory_pressure_paths", { count: pathSize });
+ }
+ return null;
+ },
batteryStatusLabel() {
if (!this.batteryStatus || !this.batteryStatus.supported) {
return this.$t("about.env_battery_unavailable");
@@ -1661,6 +1780,13 @@ export default {
formatBytes: function (bytes) {
return Utils.formatBytes(bytes);
},
+ formatCpuPercent(value) {
+ const n = typeof value === "number" ? value : Number(value);
+ if (!Number.isFinite(n)) {
+ return this.$t("about.path_unknown");
+ }
+ return `${n.toFixed(n >= 10 ? 0 : 1)}%`;
+ },
formatNumber: function (num) {
return Utils.formatNumber(num);
},
diff --git a/meshchatx/src/frontend/components/layout/BatteryStatusChip.vue b/meshchatx/src/frontend/components/layout/BatteryStatusChip.vue
deleted file mode 100644
index 57a60864..00000000
--- a/meshchatx/src/frontend/components/layout/BatteryStatusChip.vue
+++ /dev/null
@@ -1,143 +0,0 @@
-<template>
- <button
- v-if="visible"
- type="button"
- class="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-semibold tabular-nums transition-colors"
- :class="chipClass"
- :title="titleText"
- :aria-label="titleText"
- @click="onClick"
- >
- <MaterialDesignIcon :icon-name="iconName" class="h-4 w-4 shrink-0" />
- <span>{{ levelLabel }}</span>
- </button>
-</template>
-
-<script>
-// SPDX-License-Identifier: 0BSD
-
-import MaterialDesignIcon from "../MaterialDesignIcon.vue";
-import { batteryStatusIconName, getDeviceBatteryStatus, shouldShowBatteryChip } from "../../js/deviceBattery.js";
-
-const POLL_MS = 60000;
-
-export default {
- name: "BatteryStatusChip",
- components: {
- MaterialDesignIcon,
- },
- emits: ["open-about"],
- data() {
- return {
- status: null,
- pollTimer: null,
- webBattery: null,
- };
- },
- computed: {
- visible() {
- return shouldShowBatteryChip(this.status);
- },
- iconName() {
- return batteryStatusIconName(this.status);
- },
- levelLabel() {
- if (this.status?.level == null) {
- return "";
- }
- return `${this.status.level}%`;
- },
- titleText() {
- if (!this.status?.supported) {
- return this.$t("app.battery_unavailable");
- }
- const level = this.status.level != null ? `${this.status.level}%` : this.$t("about.path_unknown");
- if (this.status.charging === true) {
- return this.$t("app.battery_charging_title", { percent: level });
- }
- if (this.status.charging === false) {
- return this.$t("app.battery_discharging_title", { percent: level });
- }
- return this.$t("app.battery_level_title", { percent: level });
- },
- chipClass() {
- const level = this.status?.level;
- if (this.status?.charging) {
- return "text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-950/40";
- }
- if (level != null && level <= 15) {
- return "text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/40";
- }
- if (level != null && level <= 30) {
- return "text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950/40";
- }
- return "text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800";
- },
- },
- mounted() {
- this.refresh();
- this.pollTimer = setInterval(() => {
- this.refresh();
- }, POLL_MS);
- },
- beforeUnmount() {
- if (this.pollTimer) {
- clearInterval(this.pollTimer);
- this.pollTimer = null;
- }
- this.detachWebBatteryListeners();
- },
- methods: {
- onClick() {
- this.$emit("open-about");
- if (this.$router) {
- this.$router.push({ name: "about" });
- }
- },
- detachWebBatteryListeners() {
- if (!this.webBattery) {
- return;
- }
- try {
- this.webBattery.removeEventListener("levelchange", this.onWebBatteryChange);
- this.webBattery.removeEventListener("chargingchange", this.onWebBatteryChange);
- } catch {
- // ignore
- }
- this.webBattery = null;
- },
- onWebBatteryChange() {
- this.refresh();
- },
- async attachWebBatteryListeners() {
- if (this.webBattery) {
- return;
- }
- if (typeof navigator === "undefined" || typeof navigator.getBattery !== "function") {
- return;
- }
- try {
- const battery = await navigator.getBattery();
- if (!battery) {
- return;
- }
- this.webBattery = battery;
- battery.addEventListener("levelchange", this.onWebBatteryChange);
- battery.addEventListener("chargingchange", this.onWebBatteryChange);
- } catch {
- // Browser may deny Battery Status API.
- }
- },
- async refresh() {
- try {
- this.status = await getDeviceBatteryStatus();
- if (this.status?.source === "web") {
- await this.attachWebBatteryListeners();
- }
- } catch {
- this.status = null;
- }
- },
- },
-};
-</script>
diff --git a/meshchatx/src/frontend/js/deviceBattery.js b/meshchatx/src/frontend/js/deviceBattery.js
index 0cba5b4a..497c9594 100644
--- a/meshchatx/src/frontend/js/deviceBattery.js
+++ b/meshchatx/src/frontend/js/deviceBattery.js
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: 0BSD
/**
- * Host device battery probe for laptops and mobile.
+ * Host device battery probe for laptops and mobile shells.
*
- * Order: Android bridge, Electron IPC, then Chromium Battery Status API.
- * Returns null when the runtime cannot expose battery state.
+ * Order: Android bridge, then Electron IPC.
+ * The Chromium Battery Status API is opt-in only. Headless and Docker
+ * Chromium builds often report a fake "charging 100%" reading.
*/
import AndroidBridge from "./rnode/AndroidBridge.js";
@@ -160,11 +161,16 @@ async function probeWebBattery() {
}
/**
- * Read current host battery status when the platform supports it.
+ * Read host battery when a native shell exposes it.
*
+ * Web Battery Status is off by default (Docker / headless Chromium lies).
+ * Pass `{ allowWeb: true }` only when the caller accepts that risk.
+ *
+ * @param {{ allowWeb?: boolean }} [options]
* @returns {Promise<DeviceBatteryStatus|null>}
*/
-export async function getDeviceBatteryStatus() {
+export async function getDeviceBatteryStatus(options = {}) {
+ const allowWeb = Boolean(options.allowWeb);
const androidStatus = await probeAndroidBattery();
if (androidStatus) {
return androidStatus;
@@ -173,7 +179,10 @@ export async function getDeviceBatteryStatus() {
if (electronStatus) {
return electronStatus;
}
- return probeWebBattery();
+ if (allowWeb) {
+ return probeWebBattery();
+ }
+ return null;
}
/**
@@ -206,11 +215,114 @@ export function batteryStatusIconName(status) {
}
/**
- * Whether the header chip should be visible.
+ * Whether a reading came from a native host shell (not browser fakes).
*
* @param {DeviceBatteryStatus|null|undefined} status
* @returns {boolean}
*/
-export function shouldShowBatteryChip(status) {
- return Boolean(status && status.supported && status.level != null);
+export function isNativeBatteryStatus(status) {
+ return Boolean(status && status.supported && (status.source === "android" || status.source === "electron"));
+}
+
+/**
+ * Format a process create_time (unix seconds) as a short uptime label.
+ *
+ * @param {unknown} createTime
+ * @param {number} [nowMs]
+ * @returns {string|null}
+ */
+export function formatProcessUptime(createTime, nowMs = Date.now()) {
+ const started = typeof createTime === "number" ? createTime : Number(createTime);
+ if (!Number.isFinite(started) || started <= 0) {
+ return null;
+ }
+ const seconds = Math.max(0, Math.floor(nowMs / 1000 - started));
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ if (days > 0) {
+ return `${days}d ${hours}h`;
+ }
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ }
+ if (minutes > 0) {
+ return `${minutes}m ${secs}s`;
+ }
+ return `${secs}s`;
+}
+
+/**
+ * @typedef {object} AppBatteryUsage
+ * @property {number|null} [estimated_percent_per_hour]
+ * @property {string|null} [intensity]
+ * @property {number|null} [machine_share_percent]
+ * @property {number|null} [avg_cpu_percent]
+ * @property {string|null} [confidence]
+ */
+
+/**
+ * Primary label for estimated MeshChatX battery drain.
+ *
+ * @param {AppBatteryUsage|null|undefined} usage
+ * @param {(key: string, values?: object) => string} t
+ * @returns {string|null}
+ */
+export function formatAppBatteryUsageLabel(usage, t) {
+ if (!usage || typeof usage !== "object") {
+ return null;
+ }
+ if (usage.confidence === "warming_up" || usage.estimated_percent_per_hour == null) {
+ return t("about.app_battery_use_warming");
+ }
+ const rate = Number(usage.estimated_percent_per_hour);
+ if (!Number.isFinite(rate)) {
+ return null;
+ }
+ const rateText = `${rate.toFixed(rate >= 10 ? 0 : 1)}%/hr`;
+ if (usage.intensity) {
+ return t("about.app_battery_use_with_intensity", {
+ rate: rateText,
+ intensity: t(`about.app_battery_intensity_${usage.intensity}`),
+ });
+ }
+ return t("about.app_battery_use_rate", { rate: rateText });
+}
+
+/**
+ * Secondary label for MeshChatX share of device CPU capacity.
+ *
+ * @param {AppBatteryUsage|null|undefined} usage
+ * @param {(key: string, values?: object) => string} t
+ * @returns {string|null}
+ */
+export function formatAppBatteryShareLabel(usage, t) {
+ if (!usage || typeof usage !== "object") {
+ return null;
+ }
+ const share = Number(usage.machine_share_percent);
+ if (!Number.isFinite(share)) {
+ return null;
+ }
+ return t("about.app_battery_share_value", {
+ percent: share.toFixed(share >= 10 ? 0 : 1),
+ });
+}
+
+/**
+ * Tone class for estimated drain intensity.
+ *
+ * @param {AppBatteryUsage|null|undefined} usage
+ * @returns {string}
+ */
+export function appBatteryUsageToneClass(usage) {
+ const intensity = usage?.intensity;
+ if (intensity === "very_high" || intensity === "high") {
+ return "text-amber-700 dark:text-amber-300";
+ }
+ if (intensity === "moderate") {
+ return "text-sky-700 dark:text-sky-300";
+ }
+ return "";
}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 662b000e..e792e2b1 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1189,7 +1189,26 @@
"env_battery": "Akku",
"env_battery_unavailable": "Auf diesem Gerät nicht verfügbar",
"env_battery_charging": "Lädt {percent}",
- "env_battery_on_battery": "Akku {percent}"
+ "env_battery_on_battery": "Akku {percent}",
+ "usage_insights": "MeshChatX-Auslastung",
+ "process_cpu": "Prozess-CPU",
+ "process_threads": "Threads",
+ "process_uptime": "Laufzeit",
+ "memory_pressure": "Speicherdruck",
+ "memory_pressure_relaxed": "Entspannt (wenig Speicher)",
+ "memory_pressure_paths": "{count} Pfade verfolgt",
+ "env_host_battery": "Host-Akku",
+ "app_battery_use": "Gesch. MeshChatX-Akku",
+ "app_battery_use_hint": "Schatzung aus MeshChatX-CPU-Zeit seit Start. Keine OS-Akku-Zuordnung.",
+ "app_battery_use_warming": "Messung…",
+ "app_battery_use_rate": "~{rate}",
+ "app_battery_use_with_intensity": "~{rate} ({intensity})",
+ "app_battery_share": "Gesch. CPU-Anteil",
+ "app_battery_share_value": "{percent}% des Gerats",
+ "app_battery_intensity_low": "niedrig",
+ "app_battery_intensity_moderate": "mittel",
+ "app_battery_intensity_high": "hoch",
+ "app_battery_intensity_very_high": "sehr hoch"
},
"interfaces": {
"title": "Schnittstellen",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 659f79d3..4c0b544c 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1137,7 +1137,26 @@
"env_battery": "Battery",
"env_battery_unavailable": "Unavailable on this device",
"env_battery_charging": "Charging {percent}",
- "env_battery_on_battery": "On battery {percent}"
+ "env_battery_on_battery": "On battery {percent}",
+ "usage_insights": "MeshChatX usage",
+ "process_cpu": "Process CPU",
+ "process_threads": "Threads",
+ "process_uptime": "Uptime",
+ "memory_pressure": "Memory pressure",
+ "memory_pressure_relaxed": "Relaxed (low memory)",
+ "memory_pressure_paths": "{count} paths tracked",
+ "env_host_battery": "Host battery",
+ "app_battery_use": "Est. MeshChatX battery",
+ "app_battery_use_hint": "Estimated from MeshChatX CPU time since start. Not an OS battery attribution.",
+ "app_battery_use_warming": "Measuring…",
+ "app_battery_use_rate": "~{rate}",
+ "app_battery_use_with_intensity": "~{rate} ({intensity})",
+ "app_battery_share": "Est. CPU share",
+ "app_battery_share_value": "{percent}% of device",
+ "app_battery_intensity_low": "low",
+ "app_battery_intensity_moderate": "moderate",
+ "app_battery_intensity_high": "high",
+ "app_battery_intensity_very_high": "very high"
},
"interfaces": {
"title": "Interfaces",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 7b4ee7b7..d6211e6f 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1137,7 +1137,26 @@
"env_battery": "Batería",
"env_battery_unavailable": "No disponible en este dispositivo",
"env_battery_charging": "Cargando {percent}",
- "env_battery_on_battery": "En batería {percent}"
+ "env_battery_on_battery": "En batería {percent}",
+ "usage_insights": "Uso de MeshChatX",
+ "process_cpu": "CPU del proceso",
+ "process_threads": "Hilos",
+ "process_uptime": "Tiempo activo",
+ "memory_pressure": "Presion de memoria",
+ "memory_pressure_relaxed": "Relajado (poca memoria)",
+ "memory_pressure_paths": "{count} rutas rastreadas",
+ "env_host_battery": "Bateria del host",
+ "app_battery_use": "Bat. est. MeshChatX",
+ "app_battery_use_hint": "Estimacion por tiempo de CPU de MeshChatX desde el inicio. No es atribucion del SO.",
+ "app_battery_use_warming": "Midiendo…",
+ "app_battery_use_rate": "~{rate}",
+ "app_battery_use_with_intensity": "~{rate} ({intensity})",
+ "app_battery_share": "Cuota CPU est.",
+ "app_battery_share_value": "{percent}% del dispositivo",
+ "app_battery_intensity_low": "baja",
+ "app_battery_intensity_moderate": "moderada",
+ "app_battery_intensity_high": "alta",
+ "app_battery_intensity_very_high": "muy alta"
},
"interfaces": {
"title": "Interfaces",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 68d7d66a..a01d9593 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1137,7 +1137,26 @@
"env_battery": "Akku",
"env_battery_unavailable": "Ei saatavilla tällä laitteella",
"env_battery_charging": "Lataa {percent}",
- "env_battery_on_battery": "Akulla {percent}"
+ "env_battery_on_battery": "Akulla {percent}",
+ "usage_insights": "MeshChatX-kaytto",
+ "process_cpu": "Prosessin CPU",
+ "process_threads": "Sailkeet",
+ "process_uptime": "Kayttoaaika",
+ "memory_pressure": "Muistipaine",
+ "memory_pressure_relaxed": "Lievitetty (vahan muistia)",
+ "memory_pressure_paths": "{count} polkua seurannassa",
+ "env_host_battery": "Isannan akku",
+ "app_battery_use": "Arvio MeshChatX-akku",
+ "app_battery_use_hint": "Arvio MeshChatX-CPU-ajasta kaynnistyksesta. Ei kayttojarjestelman akkumattribuutiota.",
+ "app_battery_use_warming": "Mitataan…",
+ "app_battery_use_rate": "~{rate}",
+ "app_battery_use_with_intensity": "~{rate} ({intensity})",
+ "app_battery_share": "Arvio CPU-osuus",
+ "app_battery_share_value": "{percent}% laitteesta",
+ "app_battery_intensity_low": "matala",
+ "app_battery_intensity_moderate": "kohtalainen",
+ "app_battery_intensity_high": "korkea",
+ "app_battery_intensity_very_high": "hyvin korkea"
},
"interfaces": {
"title": "Sovittimet",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 9c4aee77..c0ac74bf 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1137,7 +1137,26 @@
"env_battery": "Batterie",
"env_battery_unavailable": "Indisponible sur cet appareil",
"env_battery_charging": "En charge {percent}",
- "env_battery_on_battery": "Sur batterie {percent}"
+ "env_battery_on_battery": "Sur batterie {percent}",
+ "usage_insights": "Utilisation MeshChatX",
+ "process_cpu": "CPU du processus",
+ "process_threads": "Threads",
+ "process_uptime": "Duree d'activite",
+ "memory_pressure": "Pression memoire",
+ "memory_pressure_relaxed": "Assoupli (memoire basse)",
+ "memory_pressure_paths": "{count} chemins suivis",
+ "env_host_battery": "Batterie hote",
+ "app_battery_use": "Batt. est. MeshChatX",
+ "app_battery_use_hint": "Estimation a partir du temps CPU MeshChatX depuis le demarrage. Pas une attribution OS.",
+ "app_battery_use_warming": "Mesure…",
+ "app_battery_use_rate": "~{rate}",
+ "app_battery_use_with_intensity": "~{rate} ({intensity})",
+ "app_battery_share": "Part CPU est.",
+ "app_battery_share_value": "{percent}% de l'appareil",
+ "app_battery_intensity_low": "faible",
+ "app_battery_intensity_moderate": "moderee",
+ "app_battery_intensity_high": "elevee",
+ "app_battery_intensity_very_high": "tres elevee"
},
"interfaces": {
"title": "Interfaces",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 80f958d3..efc2c49b 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1189,7 +1189,26 @@
"env_battery": "Batteria",
"env_battery_unavailable": "Non disponibile su questo dispositivo",
"env_battery_charging": "In carica {percent}",
- "env_battery_on_battery": "A batteria {percent}"
+ "env_battery_on_battery": "A batteria {percent}",
+ "usage_insights": "Utilizzo MeshChatX",
+ "process_cpu": "CPU processo",
+ "process_threads": "Thread",
+ "process_uptime": "Tempo di attivita",
+ "memory_pressure": "Pressione memoria",
+ "memory_pressure_relaxed": "Rilassato (poca memoria)",
+ "memory_pressure_paths": "{count} percorsi tracciati",
+ "env_host_battery": "Batteria host",
+ "app_battery_use": "Batt. stim. MeshChatX",
+ "app_battery_use_hint": "Stima dal tempo CPU di MeshChatX dall'avvio. Non e attribuzione del SO.",
+ "app_battery_use_warming": "Misurazione…",
+ "app_battery_use_rate": "~{rate}",
+ "app_battery_use_with_intensity": "~{rate} ({intensity})",
+ "app_battery_share": "Quota CPU stim.",
+ "app_battery_share_value": "{percent}% del dispositivo",
+ "app_battery_intensity_low": "bassa",
+ "app_battery_intensity_moderate": "moderata",
+ "app_battery_intensity_high": "alta",
+ "app_battery_intensity_very_high": "molto alta"
},
"interfaces": {
"title": "Interfacce",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 58c8161f..317d8515 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1137,7 +1137,26 @@
"env_battery": "Batterij",
"env_battery_unavailable": "Niet beschikbaar op dit apparaat",
"env_battery_charging": "Opladen {percent}",
- "env_battery_on_battery": "Op batterij {percent}"
+ "env_battery_on_battery": "Op batterij {percent}",
+ "usage_insights": "MeshChatX-gebruik",
+ "process_cpu": "Proces-CPU",
+ "process_threads": "Threads",
+ "process_uptime": "Uptime",
+ "memory_pressure": "Geheugendruk",
+ "memory_pressure_relaxed": "Versoepeld (weinig geheugen)",
+ "memory_pressure_paths": "{count} paden gevolgd",
+ "env_host_battery": "Hostbatterij",
+ "app_battery_use": "Gesch. MeshChatX-batterij",
+ "app_battery_use_hint": "Schatting uit MeshChatX-CPU-tijd sinds start. Geen OS-batterijtoewijzing.",
+ "app_battery_use_warming": "Meten…",
+ "app_battery_use_rate": "~{rate}",
+ "app_battery_use_with_intensity": "~{rate} ({intensity})",
+ "app_battery_share": "Gesch. CPU-aandeel",
+ "app_battery_share_value": "{percent}% van apparaat",
+ "app_battery_intensity_low": "laag",
+ "app_battery_intensity_moderate": "matig",
+ "app_battery_intensity_high": "hoog",
+ "app_battery_intensity_very_high": "zeer hoog"
},
"interfaces": {
"title": "Interfaces",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 9ef00d37..ae0a743a 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1189,7 +1189,26 @@
"env_battery": "Батарея",
"env_battery_unavailable": "Недоступно на этом устройстве",
"env_battery_charging": "Зарядка {percent}",
- "env_battery_on_battery": "От батареи {percent}"
+ "env_battery_on_battery": "От батареи {percent}",
+ "usage_insights": "Нагрузка MeshChatX",
+ "process_cpu": "CPU процесса",
+ "process_threads": "Потоки",
+ "process_uptime": "Время работы",
+ "memory_pressure": "Давление памяти",
+ "memory_pressure_relaxed": "Ослаблен (мало памяти)",
+ "memory_pressure_paths": "{count} путей отслеживается",
+ "env_host_battery": "Батарея хоста",
+ "app_battery_use": "Оц. батарея MeshChatX",
+ "app_battery_use_hint": "Оценка по CPU-времени MeshChatX с запуска. Не системная атрибуция.",
+ "app_battery_use_warming": "Измерение…",
+ "app_battery_use_rate": "~{rate}",
+ "app_battery_use_with_intensity": "~{rate} ({intensity})",
+ "app_battery_share": "Оц. доля CPU",
+ "app_battery_share_value": "{percent}% устройства",
+ "app_battery_intensity_low": "низкая",
+ "app_battery_intensity_moderate": "средняя",
+ "app_battery_intensity_high": "высокая",
+ "app_battery_intensity_very_high": "очень высокая"
},
"interfaces": {
"title": "Интерфейсы",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 0f7eee7f..cb7c321f 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1137,7 +1137,26 @@
"env_battery": "电池",
"env_battery_unavailable": "此设备不可用",
"env_battery_charging": "充电中 {percent}",
- "env_battery_on_battery": "使用电池 {percent}"
+ "env_battery_on_battery": "使用电池 {percent}",
+ "usage_insights": "MeshChatX 占用",
+ "process_cpu": "进程 CPU",
+ "process_threads": "线程",
+ "process_uptime": "运行时间",
+ "memory_pressure": "内存压力",
+ "memory_pressure_relaxed": "已放宽(低内存)",
+ "memory_pressure_paths": "已跟踪 {count} 条路径",
+ "env_host_battery": "主机电池",
+ "app_battery_use": "MeshChatX 预估耗电",
+ "app_battery_use_hint": "根据 MeshChatX 启动以来的 CPU 时间估算,不是系统电池归因。",
+ "app_battery_use_warming": "测量中…",
+ "app_battery_use_rate": "~{rate}",
+ "app_battery_use_with_intensity": "~{rate}({intensity})",
+ "app_battery_share": "预估 CPU 占比",
+ "app_battery_share_value": "设备的 {percent}%",
+ "app_battery_intensity_low": "低",
+ "app_battery_intensity_moderate": "中",
+ "app_battery_intensity_high": "高",
+ "app_battery_intensity_very_high": "很高"
},
"interfaces": {
"title": "接口",
diff --git a/tests/backend/test_battery_usage_estimate.py b/tests/backend/test_battery_usage_estimate.py
new file mode 100644
index 00000000..fda91eae
--- /dev/null
+++ b/tests/backend/test_battery_usage_estimate.py
@@ -0,0 +1,80 @@
+# SPDX-License-Identifier: 0BSD
+
+from meshchatx.src.backend.battery_usage_estimate import (
+ BatteryUsageTracker,
+ drain_intensity,
+ estimate_battery_usage,
+)
+
+
+def test_estimate_scales_with_cpu_time():
+ idle = estimate_battery_usage(
+ cpu_time_seconds=6.0,
+ uptime_seconds=600.0,
+ cpu_count=4,
+ )
+ assert idle is not None
+ assert idle["avg_cpu_percent"] == 1.0
+ assert idle["machine_share_percent"] == 0.2
+ assert idle["estimated_percent_per_hour"] == 0.1
+ assert idle["intensity"] == "low"
+ assert idle["confidence"] == "estimate"
+ assert idle["method"] == "cpu_time"
+
+ busy = estimate_battery_usage(
+ cpu_time_seconds=300.0,
+ uptime_seconds=600.0,
+ cpu_count=4,
+ )
+ assert busy is not None
+ assert busy["avg_cpu_percent"] == 50.0
+ assert busy["estimated_percent_per_hour"] == 5.0
+ assert busy["intensity"] == "high"
+
+
+def test_estimate_warms_up_before_rate():
+ early = estimate_battery_usage(
+ cpu_time_seconds=1.0,
+ uptime_seconds=10.0,
+ cpu_count=2,
+ )
+ assert early is not None
+ assert early["estimated_percent_per_hour"] is None
+ assert early["confidence"] == "warming_up"
+ assert early["intensity"] is None
+
+
+def test_estimate_rejects_bad_inputs():
+ assert estimate_battery_usage(cpu_time_seconds=None, uptime_seconds=10) is None
+ assert estimate_battery_usage(cpu_time_seconds=-1, uptime_seconds=10) is None
+ assert estimate_battery_usage(cpu_time_seconds=1, uptime_seconds=0) is None
+
+
+def test_drain_intensity_buckets():
+ assert drain_intensity(0.2) == "low"
+ assert drain_intensity(1.0) == "moderate"
+ assert drain_intensity(3.0) == "high"
+ assert drain_intensity(9.0) == "very_high"
+ assert drain_intensity(None) is None
+
+
+def test_tracker_snapshot_from_fake_process():
+ class _Times:
+ user = 12.0
+ system = 3.0
+
+ class _Proc:
+ def cpu_times(self):
+ return _Times()
+
+ def create_time(self):
+ import time
+
+ return time.time() - 120.0
+
+ tracker = BatteryUsageTracker()
+ snap = tracker.snapshot(_Proc())
+ assert snap is not None
+ assert snap["cpu_time_seconds"] == 15.0
+ assert snap["uptime_seconds"] >= 119.0
+ assert snap["estimated_percent_per_hour"] is not None
diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js
index f1bd1f8a..f1a9e047 100644
--- a/tests/frontend/AboutPage.test.js
+++ b/tests/frontend/AboutPage.test.js
@@ -518,13 +518,34 @@ describe("AboutPage.vue", () => {
expect(wrapper.text()).not.toContain("app.landlock_status");
});
- it("loads and shows host battery status in environment info", async () => {
- navigator.getBattery = vi.fn(async () => ({ level: 0.81, charging: true }));
-
+ it("shows MeshChatX usage insights from app info", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info") {
return Promise.resolve({
- data: { app_info: { version: "1.0.0", host_platform: "linux" } },
+ data: {
+ app_info: {
+ version: "1.0.0",
+ host_platform: "linux",
+ memory_usage: {
+ rss: 128 * 1024 * 1024,
+ vms: 256 * 1024 * 1024,
+ cpu_percent: 2.5,
+ num_threads: 18,
+ create_time: Date.now() / 1000 - 125,
+ },
+ battery_usage: {
+ avg_cpu_percent: 4.0,
+ machine_share_percent: 1.0,
+ estimated_percent_per_hour: 0.4,
+ intensity: "low",
+ confidence: "estimate",
+ method: "cpu_time",
+ },
+ reticulum_stats: {
+ memory_cleanup: { path_table_size: 42, sqlite_relaxed: false },
+ },
+ },
+ },
});
}
if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
@@ -536,11 +557,16 @@ describe("AboutPage.vue", () => {
const wrapper = mountAboutPage();
await vi.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
- await wrapper.vm.refreshBatteryStatus();
+ await wrapper.vm.getAppInfo();
await wrapper.vm.$nextTick();
- expect(wrapper.text()).toContain("about.env_battery");
- expect(wrapper.vm.batteryStatus?.level).toBe(81);
- expect(wrapper.vm.batteryStatusLabel).toContain("81%");
+ expect(wrapper.text()).toContain("about.usage_insights");
+ expect(wrapper.text()).toContain("about.app_battery_use");
+ expect(wrapper.vm.batteryUsageLabel).toContain("0.4%/hr");
+ expect(wrapper.text()).toContain("about.memory_rss");
+ expect(wrapper.text()).toContain("about.process_cpu");
+ expect(wrapper.vm.processUptimeLabel).toMatch(/2m/);
+ expect(wrapper.vm.showHostBattery).toBe(false);
+ expect(wrapper.text()).not.toContain("about.env_battery");
});
});
diff --git a/tests/frontend/BatteryStatusChip.test.js b/tests/frontend/BatteryStatusChip.test.js
deleted file mode 100644
index b83be83c..00000000
--- a/tests/frontend/BatteryStatusChip.test.js
+++ /dev/null
@@ -1,99 +0,0 @@
-// SPDX-License-Identifier: 0BSD
-
-import { mount, flushPromises } from "@vue/test-utils";
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import BatteryStatusChip from "@/components/layout/BatteryStatusChip.vue";
-import * as deviceBattery from "@/js/deviceBattery.js";
-
-vi.mock("@/js/deviceBattery.js", async () => {
- const actual = await vi.importActual("@/js/deviceBattery.js");
- return {
- ...actual,
- getDeviceBatteryStatus: vi.fn(),
- };
-});
-
-describe("BatteryStatusChip.vue", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- const mountChip = () =>
- mount(BatteryStatusChip, {
- global: {
- mocks: {
- $t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
- $router: { push: vi.fn() },
- },
- stubs: {
- MaterialDesignIcon: true,
- },
- },
- });
-
- it("hides when battery status is unavailable", async () => {
- deviceBattery.getDeviceBatteryStatus.mockResolvedValue(null);
- const wrapper = mountChip();
- await flushPromises();
- expect(wrapper.find("button").exists()).toBe(false);
- });
-
- it("hides when probe throws", async () => {
- deviceBattery.getDeviceBatteryStatus.mockRejectedValue(new Error("boom"));
- const wrapper = mountChip();
- await flushPromises();
- expect(wrapper.find("button").exists()).toBe(false);
- expect(wrapper.vm.status).toBe(null);
- });
-
- it("renders level and navigates to about on click", async () => {
- deviceBattery.getDeviceBatteryStatus.mockResolvedValue({
- supported: true,
- level: 42,
- charging: false,
- source: "web",
- });
- const wrapper = mountChip();
- await flushPromises();
- const button = wrapper.find("button");
- expect(button.exists()).toBe(true);
- expect(button.text()).toContain("42%");
- await button.trigger("click");
- expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ name: "about" });
- });
-
- it("hides after a later refresh loses battery support", async () => {
- deviceBattery.getDeviceBatteryStatus
- .mockResolvedValueOnce({
- supported: true,
- level: 20,
- charging: false,
- source: "electron",
- })
- .mockResolvedValueOnce(null);
- const wrapper = mountChip();
- await flushPromises();
- expect(wrapper.find("button").exists()).toBe(true);
-
- vi.advanceTimersByTime(60000);
- await flushPromises();
- expect(wrapper.find("button").exists()).toBe(false);
- });
-
- it("shows low-battery styling under 15 percent", async () => {
- deviceBattery.getDeviceBatteryStatus.mockResolvedValue({
- supported: true,
- level: 8,
- charging: false,
- source: "android",
- });
- const wrapper = mountChip();
- await flushPromises();
- expect(wrapper.find("button").classes().join(" ")).toContain("text-red-700");
- });
-});
diff --git a/tests/frontend/deviceBattery.test.js b/tests/frontend/deviceBattery.test.js
index 6c435642..b0a76b70 100644
--- a/tests/frontend/deviceBattery.test.js
+++ b/tests/frontend/deviceBattery.test.js
@@ -2,12 +2,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
+ appBatteryUsageToneClass,
batteryStatusIconName,
+ formatAppBatteryShareLabel,
+ formatAppBatteryUsageLabel,
+ formatProcessUptime,
getDeviceBatteryStatus,
+ isNativeBatteryStatus,
normalizeBatteryPercent,
normalizeBatteryStatus,
parseAndroidBatteryPayload,
- shouldShowBatteryChip,
} from "@/js/deviceBattery.js";
describe("deviceBattery", () => {
@@ -82,20 +86,20 @@ describe("deviceBattery", () => {
expect(normalizeBatteryStatus({ level: 10, is_charging: false }).charging).toBe(false);
});
- it("uses web scale when source is web so level 1 means 100%", () => {
+ it("uses unitFraction only for web source when level is 1", () => {
expect(normalizeBatteryStatus({ level: 1, charging: true }, "web").level).toBe(100);
expect(normalizeBatteryStatus({ level: 1, charging: false }, "android").level).toBe(1);
});
- it("returns null when both level and charging are missing", () => {
+ it("rejects empty objects without level or charging", () => {
expect(normalizeBatteryStatus({ source: "web" })).toBe(null);
expect(normalizeBatteryStatus({ level: "bad" })).toBe(null);
});
});
- describe("parseAndroidBatteryPayload edge cases", () => {
- it("handles object payloads and empty failures", () => {
- expect(parseAndroidBatteryPayload({ level: 9, charging: true })).toEqual({
+ describe("parseAndroidBatteryPayload", () => {
+ it("parses JSON strings and objects", () => {
+ expect(parseAndroidBatteryPayload('{"level":9,"charging":true}')).toEqual({
supported: true,
level: 9,
charging: true,
@@ -121,16 +125,50 @@ describe("deviceBattery", () => {
expect(batteryStatusIconName({ supported: true, charging: false, level: 55 })).toBe("battery-medium");
});
- it("shows chip only when level is known", () => {
- expect(shouldShowBatteryChip(null)).toBe(false);
- expect(shouldShowBatteryChip({ supported: true, level: null })).toBe(false);
- expect(shouldShowBatteryChip({ supported: false, level: 50 })).toBe(false);
- expect(shouldShowBatteryChip({ supported: true, level: 0 })).toBe(true);
- expect(shouldShowBatteryChip({ supported: true, level: 55 })).toBe(true);
+ it("marks only android and electron as native battery sources", () => {
+ expect(isNativeBatteryStatus(null)).toBe(false);
+ expect(isNativeBatteryStatus({ supported: true, source: "web", level: 100 })).toBe(false);
+ expect(isNativeBatteryStatus({ supported: true, source: "android", level: 50 })).toBe(true);
+ expect(isNativeBatteryStatus({ supported: true, source: "electron", level: 12 })).toBe(true);
+ });
+
+ it("formats process uptime from create_time", () => {
+ const now = 1_700_000_000_000;
+ expect(formatProcessUptime(now / 1000 - 45, now)).toBe("45s");
+ expect(formatProcessUptime(now / 1000 - 125, now)).toBe("2m 5s");
+ expect(formatProcessUptime(now / 1000 - 3700, now)).toBe("1h 1m");
+ expect(formatProcessUptime(now / 1000 - 90000, now)).toBe("1d 1h");
+ expect(formatProcessUptime(null)).toBe(null);
+ });
+
+ it("formats estimated app battery usage labels", () => {
+ const t = (key, values = {}) => {
+ if (key === "about.app_battery_use_warming") return "warming";
+ if (key === "about.app_battery_use_with_intensity") {
+ return `~${values.rate} (${values.intensity})`;
+ }
+ if (key === "about.app_battery_use_rate") return `~${values.rate}`;
+ if (key.startsWith("about.app_battery_intensity_")) return key.split("_").pop();
+ if (key === "about.app_battery_share_value") return `${values.percent}% of device`;
+ return key;
+ };
+ expect(formatAppBatteryUsageLabel({ confidence: "warming_up" }, t)).toBe("warming");
+ expect(
+ formatAppBatteryUsageLabel(
+ {
+ confidence: "estimate",
+ estimated_percent_per_hour: 1.4,
+ intensity: "moderate",
+ },
+ t
+ )
+ ).toBe("~1.4%/hr (moderate)");
+ expect(formatAppBatteryShareLabel({ machine_share_percent: 2.5 }, t)).toBe("2.5% of device");
+ expect(appBatteryUsageToneClass({ intensity: "high" })).toContain("amber");
});
describe("getDeviceBatteryStatus probe order and failures", () => {
- it("prefers android bridge over web battery", async () => {
+ it("prefers android bridge and skips web by default", async () => {
window.MeshChatXAndroid = {
getBatteryStatus: () => '{"level":77,"charging":true}',
};
@@ -145,22 +183,28 @@ describe("deviceBattery", () => {
expect(navigator.getBattery).not.toHaveBeenCalled();
});
- it("falls back when android returns empty or throws", async () => {
+ it("ignores docker-style web battery unless allowWeb is set", async () => {
+ navigator.getBattery = vi.fn(async () => ({ level: 1, charging: true }));
+ await expect(getDeviceBatteryStatus()).resolves.toBe(null);
+ expect(navigator.getBattery).not.toHaveBeenCalled();
+
+ await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toEqual({
+ supported: true,
+ level: 100,
+ charging: true,
+ source: "web",
+ });
+ });
+
+ it("falls back to web only when allowWeb and android fails", async () => {
window.MeshChatXAndroid = {
getBatteryStatus: () => {
throw new Error("bridge boom");
},
};
navigator.getBattery = vi.fn(async () => ({ level: 0.33, charging: false }));
- await expect(getDeviceBatteryStatus()).resolves.toEqual({
- supported: true,
- level: 33,
- charging: false,
- source: "web",
- });
-
- window.MeshChatXAndroid = { getBatteryStatus: () => "" };
- await expect(getDeviceBatteryStatus()).resolves.toEqual({
+ await expect(getDeviceBatteryStatus()).resolves.toBe(null);
+ await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toEqual({
supported: true,
level: 33,
charging: false,
@@ -186,7 +230,8 @@ describe("deviceBattery", () => {
expect(navigator.getBattery).not.toHaveBeenCalled();
window.electron.getBatteryStatus = vi.fn().mockRejectedValue(new Error("ipc fail"));
- await expect(getDeviceBatteryStatus()).resolves.toEqual({
+ await expect(getDeviceBatteryStatus()).resolves.toBe(null);
+ await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toEqual({
supported: true,
level: 90,
charging: true,
@@ -198,10 +243,10 @@ describe("deviceBattery", () => {
navigator.getBattery = vi.fn(async () => {
throw new Error("denied");
});
- await expect(getDeviceBatteryStatus()).resolves.toBe(null);
+ await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toBe(null);
navigator.getBattery = vi.fn(async () => null);
- await expect(getDeviceBatteryStatus()).resolves.toBe(null);
+ await expect(getDeviceBatteryStatus({ allowWeb: true })).resolves.toBe(null);
});
it("returns null when no probe is available", async () => {
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────